Proyecto Empresa FAST

Hito 3: Resultados

Preparación del ambiente de Trabajo

Problema y solución

Descripción

Es claro que el confinamiento influyó en un mayor y acelerado crecimiento de la demanda de servicios de logística dado el boom del e-commerce. Y en este sentido, es esencial una muy buena planificación de los servicios de entrega, puesto que en estos días el único punto de contacto con el cliente en es cuando los productos llegan a casa. FAST es una empresa de entrega de medicamentos y ha encomendado, por una parte, diseñar una modelo que prediga cuál es la probabilidad de que un despacho sea exitoso/fallido determinando los principales atributos explicativos, y por otro, entender cómo se segmentan los distintos tipos de despachos, con el fin de identificar donde se concentran los clusters con peor desempeño de servicio.

Datos

Se entregan datos del mes de agosto, el cual corresponde a un mes tipo con demanda normal (sin eventos especiales de aumentos de demanda). La data entregada corresponde a registros con las características de los pedidos ( dimesiones, categorías, tipos de clientes, etc.) y también sobre la trazabilidad del pedido durante el proceso y sus movimientos asociados.

Vector Objetivo

El vector objetivo corresponde a una variable binaria que determina si el pedido llego o no en la fecha comprometida, es 1 cuando la fecha de entrega real es menor o igual a la fecha de entrega comprometida, en otro caso no se cumple la entrega y es el resultado es 0.

Solución

La solución propuesta abarca en utilizar herramientas de google cloud para lograr dos objetivos:

  1. Identificar los segmentos de servicios de entrega con peor tasa de cumplimiento
  1. Obtener un modelo predictivo que permita determinar la probabilidad del resultado de una entrega lo antes posible.

Módulos adicionales

Se trabajan con diferentes módulos, en caso de falta alguno se pueden instalar con:

In [ ]:
#pandas
!pip install pandas
#numpy
!pip install numpy
#matplotlib
!pip install matplotlib
#seaborn
!pip install seaborn
#scipy
!pip install scipy
#networkx
!pip install networkx
#plotly
!pip install plotly
#yellowbrick
!pip install yellowbrick

Importación de módulos y funciones propias

Se importan librerias y funciones propias:

In [64]:
import pandas as pd
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt
import warnings
from scipy.stats import mode
from scipy import stats
from scipy.stats import ttest_ind
warnings.filterwarnings('ignore')
plt.rcParams['figure.figsize'] = (15,10)
plt.style.use('seaborn-darkgrid')
import plotly.offline as py
import plotly.graph_objects as go
import networkx as nx

from sklearn.model_selection import train_test_split
from sklearn.model_selection import GridSearchCV 

from sklearn.preprocessing import LabelEncoder

from sklearn.ensemble import AdaBoostClassifier,GradientBoostingClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, accuracy_score, precision_score

from sklearn.metrics import classification_report,accuracy_score,roc_curve,auc,confusion_matrix,plot_confusion_matrix
from sklearn.metrics import roc_auc_score,precision_score,recall_score, make_scorer,f1_score

from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_samples, silhouette_score
from yellowbrick.cluster import KElbowVisualizer, SilhouetteVisualizer

import pickle
from joblib import load, dump

from plotly.offline import iplot, init_notebook_mode
# Using plotly + cufflinks in offline mode
import cufflinks
cufflinks.go_offline(connected=True)
init_notebook_mode(connected=True)
import plotly.figure_factory as ff
pd.set_option('display.float_format', '{:.2f}'.format) ### para sacarle el formato exponencial al dataframe
import plotly.graph_objects as go

### funciones propias
from aux import *

Ingesta de datos

Se entregan dos csv con información sobre los pedidos:

  • datos_med.csv: presenta detalle de diferentes atributos de cada pedido

  • datos_fact.csv: presenta todos los movimientos que se hicieron en la vida del pedido.

Ambos csv se extraen desde la base de datos del cliente, para trabajarlos se subieron al ambiente de Google Cloud-Storage y por medio de BigQuery se genero una tabla que contenga los datos entregados con sus atributos.

In [65]:
%%bigquery df
select * from `charged-ground-301216.test_1_fast_project.datos_med`

Se revisa tamaño del dataframe y cantidad de atirbutos:

In [66]:
df.shape
Out[66]:
(2568900, 34)

Son datos de 2.568.900 registros, correspondiente a los pedidos del mes de Agosto-20.

Pre-procesamiento y limpieza inicial

Se revisa presencia de datos nulos o perdidos con función auxiliar:

In [67]:
missing_values_table(df)
Out[67]:
Missing Values % Missing Values

No se encontraron registros con datos perdidos o nulos, se puede continuar con el procesamiento.

Se genera un nuevo atributo en la base que se calcula a partir las dimensiones de peso, volumen, alto y largo. Se llama peso equivalente, donde se evalua entre el peso físico versus el peso volumétrico, quedando el mayor valor como el seleccionado. El peso volumétrico se refiere una convencion de que en un metro cúbico equivalen a 250 kgs ( largo x ancho x alto / 4000).
En base a esto se considera además, la propia clasificación tarifaria de FAST que diferencia al peso equivalente en 4 categorías:

  • Pequeño: desde 0 a 1.5 kilos.
  • Mediano: mayor a 1.5 y hasta 3 kilos.
  • Grande: mayor a 3 y hasta 4.5 kilos.
  • Sobre-dimensionado: Mayor a 4.5 kilos.

Detalle de la transformación:

In [98]:
%%bigquery df

select   CASE 
                WHEN ( ( (largo * ancho * alto) / 4000 ) > peso ) THEN CASE 
                                                                             WHEN ( ( (largo * ancho * alto) / 4000 ) <= 1.5 ) THEN 'peq' 
                                                                             WHEN ( ( (largo * ancho * alto) / 4000 ) > 1.5 and ( (largo * ancho * alto) / 4000 ) <= 3 ) THEN 'med'
                                                                             WHEN ( ( (largo * ancho * alto) / 4000 ) > 3 and ( (largo * ancho * alto) / 4000 ) <= 4.5 ) THEN 'gran'
                                                                             ELSE 'sob'
                                                                        END
                ELSE CASE 
                             WHEN ( peso <= 1.5 ) THEN 'peq'
                             WHEN ( peso > 1.5 and peso <= 3 ) THEN 'med'
                             WHEN ( peso > 3 and peso <= 4 ) THEN 'gran'
                             ELSE 'sob'
                      END
         END as dimension_pedido
        ,lower(replace(tipo_cliente,' ','_')) as tipo_cliente
        ,lower(replace(tipo_medicamento,' ','_')) as tipo_medicamento
        ,comuna_origen
        ,comuna_destino
        ,region_origen
        ,region_destino
        ,((tiene_nombre_destinatario * 2) + (tiene_correo_destinatario * 2) + (tiene_telefono_destinatario * 6)) as score_datos_contactabilidad_destintario
        ,((tiene_nombre_remitente * 2) + (tiene_correo_remitente * 2) + (tiene_telefono_remitente * 6))          as score_datos_contactabilidad_remitente
        ,lower(replace(velocidad_servicio,' ','_')) as velocidad_servicio
        ,lower(replace(tipo_envio,' ','_')) as tipo_envio
        ,lower(replace(tipo_induccion,' ','_')) as tipo_induccion
        ,case when sla_compromiso=1 then 'si' else 'no' end cumplimiento
        ,valor_contratado
        ,distancia_envio_mts
        ,satisfaccion_cliente
        ,dia_semana_admision
        ,dia_semana_entrega
        ,lower(replace(tipo_servicio,' ','_')) as tipo_servicio
        ,case when (admite_a_tiempo_para_p__ck_up = 1) THEN 'si' else 'no' end as admite_a_tiempo_para_pick_up
        ,horas_desde_creacion_hasta_compromiso
        ,case when horas_desde_creacion_hasta_compromiso<=48 then 'menor_48' else 'mayor_48' end tramo_hr_desp
        ,horas_desde_creacion_hasta_salida_primera_milla
from `charged-ground-301216.test_1_fast_project.datos_med`                               

Sobre los datos

Descripción de cada variable en el dataframe:

  • dimension_pedido: (str) variable categórica que clasifica si el pedido es pequeño (peq), mediano (med), grande (gran) y sobredimensionado (sob).
  • tipo_cliente:(str) variable categórica que define si el cliente es una persona natural (persona) o una empresa (empresa).
  • tipo_medicamento:(str) variable categórica que define si el medicamento enviado es de alto valor o normal.
  • comuna_origen: (str) variable que representa el código de la comuna desde donde se envia el medicamento.
  • comuna_destino:(str) variable que representa el código de la comuna hacia donde se envia el medicamento.
  • region_origen:(str) variable que representa el código de la región desde donde se envia el medicamento.
  • region_destino:(str) variable que representa el código de la región hacia donde se envia el medicamento.
  • score_datos_contactabilidad_destintario: (int) valor de escala de contactabilidad del destinatario, es un puntaje entregado por datos de datos personales, mail y teléfono de contactabilidad.
  • score_datos_contactabilidad_remitente:(int) valor de escala de contactabilidad del remitente, es un puntaje entregado por datos de datos personales, mail y teléfono de contactabilidad.
  • velocidad_servicio: (str) variable categórica que define la velocidad del servicio contratado va desde mismo dia hasta plus_1.
  • tipo_envio:(str) variable categórica que define si el pedido fue enviado a la puerta o por ventanilla.
  • tipo_induccion:(str) variable categórica que define si el drop es e sucursal o retirado al cliente.
  • cumplimiento: (str) vector objetivo que explica si el pedido llego o no en la promesa de entrega realizada al inicio del proceso.
  • valor_contratado: (float) valor asociado a la entrega del pedido.
  • distancia_envio_mts:(float) valor asociado a la distancia para entregar el pedido.
  • tipo_servicio:(str) variable categorica que define si el pedido es una entrega local o interregional.
  • admite_a_tiempo_para_pick_up: (str) variable categórica que clasifica si el pedido fue admitido o no a tiempo para pickup.
  • horas_desde_creacion_hasta_compromiso: (float) valor que refleja el número de horas totales que se tiene para entregar el pedido hasta el compromiso.
  • tramo_hr_desp: (str) variable que define si el despacho se entregó en un tiempo menor o igual 48 horas, o bien superior a este.
  • dia_semana_admision: (str) variable categórica que toma marcas lunes a domingo y define el día en que se crea el despacho en el sistema de FAST.
  • dia_semana_entrega: (str) variable categórica que toma marcas lunes a domingo y define el día en que se entrega el despacho.
  • horas_desde_creacion_hasta_salida_primera_milla:(float) valor que refleja el número de horas transcurridas desde la creación hasta la salida en primera milla.

Sobre las principales variables descriptivas:

In [6]:
df.describe()
Out[6]:
score_datos_contactabilidad_destintario score_datos_contactabilidad_remitente valor_contratado distancia_envio_mts satisfaccion_cliente horas_desde_creacion_hasta_compromiso horas_desde_creacion_hasta_salida_primera_milla
count 2568900.00 2568900.00 2568900.00 2568900.00 2568900.00 2568900.00 2568900.00
mean 7.72 7.03 11325.14 323790.05 3.60 48.66 22.33
std 2.03 2.53 17353.13 440732.66 0.50 24.68 35.36
min 0.00 0.00 1.73 0.00 1.00 0.00 0.00
25% 8.00 8.00 6753.38 16024.87 3.00 31.00 4.00
50% 8.00 8.00 9235.65 90529.70 4.00 37.00 11.00
75% 8.00 8.00 11624.77 464024.55 4.00 61.00 28.00
max 10.00 10.00 6624105.22 3874988.21 4.00 158.00 2863.00

Segmentacion de conjuntos de validacion y modelamiento

Sobre los conjuntos de la data a revisar, se trabajara con el 100% del conjunto de datos entregado para la modelación y para el entranmiento de modelos se dividirá en dos grupos de Test/Training con un % de 70 y 30.
Para la validación del modelo se contrastará los resultados de los modelos con un nuevo dataset correspondiente a otro mes o periodo.

Analisis descriptivo y pre seleccion de atributos

Vector Objetivo

El vector objetivo corresponde a una variable binaria que determina si el pedido llego o no en la fecha comprometida, es 1 cuando la fecha de entrega real es menor o igual a la fecha de entrega comprometida, en otro caso no se cumple la entrega y es el resultado es 0. Sobre su comportamiento:

In [7]:
eda_plots(pd.DataFrame(df['cumplimiento']),1)
  • Se nota una distribución bastante pareja para nuestro vector objetivo, donde el cerca del 60% de los pedidos cumplen y el 40% no, de modo que existen varias oportunidades de mejora.

 Atributos

Revisamos la distribucion de los atributos:

In [8]:
atributos=list(df.columns)
atributos.remove('cumplimiento')
eda_plots(df.loc[:,atributos],4)
  • Revisando los datos podemos ver que las variables continuas presentan un sesgo positivo, con un gráficos con tendencia a la izquierda, esto se podria corregir con algún tipo de normalización que evaluaremos más adelante.
  • Sobre las variables categóricas, en general se ve un desbalanceo de clases, por ejemplo la gran mayoría de los pedidos son del tipo pequeño con clientes empresa y de alto valor.

A continuación se revisa más a fondo el desbalanceo de estos atributos, al revisarlos con el vector objetivo.

Dist. atributos con respecto al vector objetivo

Se revisa cada atributo categórico y cómo es el cumplimiento del compromiso para cada segmento,

Cumplimiento según tamaño

In [9]:
g = sns.catplot(x="cumplimiento", col="dimension_pedido", col_wrap=4,data=df,kind="count", height=2.5, aspect=1.8)
  • Dado la especialidad de nuestra empresa, esta contenida dentro de la industria courrier, en la que las grandes dimensiones son multadas con tarifas mayores.
  • Dado el desbalance a las dimensiones pequeñas se obserba una pequeña alza de 3 puntos porcentuales, con respecto al cumplimiento global 60%, asociado a la facilidad del transporte y la priorización que naturalmente se realiza en la operación hacia los menores volúmenes. (Transportar más en el mismo espacio)

Cumplimiento según tipo cliente

In [10]:
g = sns.catplot(x="cumplimiento", col="tipo_cliente", col_wrap=4,data=df,kind="count", height=2.5, aspect=1.8)
  • Se observa un desbalance marcado hacia los clientes empresa que aportan un mayor volumen de medicamentos que los clientes esporádicos (Contado).
  • Se obserba que la relación entre tipo de empresa y cumplimiento es levemente la misma 60.38% empresa y 61.44% para contado, esto puede tratarse de tipo de recolección que se realiza en la primera milla, más sujeta de las condiciones operacionales de cada cliente en el crédito y más normalizadas en los retiros a oficinas comerciales.

Cumplimiento según tipo medicamento

In [11]:
g = sns.catplot(x="cumplimiento", col="tipo_medicamento", col_wrap=4,data=df,kind="count", height=2.5, aspect=1.8)
  • Se observa un desbalance marcado hacia los clientes con bajo valor.
  • Se obserba que la relación entre tipo de medicamento y el cumplimiento es mnarcadamente mayor para los de mayor valor 60.15% y 74.41% para los de mayor valor. Esto se da ya que los medicamentos de mayor valor tienen un proceso de recolección, procesamiento o clasificación diferenciado en la primera y ultima milla, es decir, tratamiento dedicado por un area especializada de valorados.

Cumplimiento según velocidad servicio

In [12]:
g = sns.catplot(x="cumplimiento", col="velocidad_servicio", col_wrap=4,data=df,kind="count", height=2.5, aspect=1.8)
  • Se observa un desbalance marcado hacia la velocidad de servicio standar y que muestra un comportamiento equivalente al global 60%
  • La velocidad de entrega SAME DAY (2.4%), muestra un comportamiento extremadamente bajo 44%, dado que los plazos pueden ser en algunos casos horas.

Cumplimiento según tipo envio

In [13]:
g = sns.catplot(x="cumplimiento", col="tipo_envio", col_wrap=4,data=df,kind="count", height=2.5, aspect=1.8)
  • A pesar del que la muestra esta sesgada hacia la entrega en domicilio v/s retiro en sucursal, ambos muestran un comprotamiento similar en terminos de cumplimiento 60.38% y 60.34%, dado que ambas distribuciones se realizan con la misma flota, y bajo el mismo sistema de optimización. (Excepto Medicamentos Valorados)

Cumplimiento según tipo de inducción

In [14]:
g = sns.catplot(x="cumplimiento", col="tipo_induccion", col_wrap=4,data=df,kind="count", height=2.5, aspect=1.8)
  • A pesar del que la muestra esta sesgada hacia el PickUP al cliente, que refuerza el desbalance hacia las ventas a clientes empresa, el retiro al cliente empresa muestra un 60.43% v/s un 60.81% de drop en sucursales, en parte a la misma justicación para el "Análisis de cumplimiento para el tipo de envío (Ultima Milla)", dado que ambas distribuciones se realizan con la misma flota, y bajo el mismo sistema de optimización. (Excepto Medicamentos Valorados)

Cumplimiento según tipo de servicio

In [15]:
g = sns.catplot(x="cumplimiento", col="tipo_servicio", col_wrap=4,data=df,kind="count", height=2.5, aspect=1.8)
  • A pesar del que la muestra no muestra un desbalance, los indicadores de cumplimiento son bastante deferentes, 52.25% para el servicio Regional que representa 59% del global y un cumplimiento del 72.43% para los servicios locales, entendiendo que el transporte Regional, con rutas de mayor kilometrajes presentan una mayor riesgo al cumplimiento.
  • Es interesante que dos segmentos que son relativamente similares tienen comportamientos tan distintos en terminos de cumplimiento, por lo que un aumento en el rendimiento de las rutas troncales podria afectar en un eventual doble digito de aumento al cumplimiento.

Cumplimiento según tiempo de pickup

In [16]:
g = sns.catplot(x="cumplimiento", col="admite_a_tiempo_para_pick_up", col_wrap=4,data=df,kind="count", height=2.5, aspect=1.8)
  • Con la intención de mostrar un indicador relacionado a al tiempo, sin afectar la oportunidad de la explotación del modelo se generó este indicador que representa si el medicamento fue admitido antes que el conductor pasara a la sucursal se haya realizado, a pesar de que su incumplimiento implica un incumplimiento automático, esto es mitigado con controles en los proceso de clasificación que reconocen estos casos y son derivados a la flota de valorados lo que explica su % de Incumplimiento 60.25% admite a tiempo para pickup, contra un 70.29% cuando es admitido fuera del plazo del pickup.

Cumplimiento según día de creación del despacho

In [17]:
g = sns.catplot(x="cumplimiento", col="dia_semana_admision", col_wrap=4,data=df,kind="count", height=2.5, aspect=1.8)
  • Se observa que en términos absolutos y relativos que los despachos creados los lunes son los que menor tasa de entrega, seguido del día martes.

Cumplimiento según día de entrega del despacho

In [18]:
g = sns.catplot(x="cumplimiento", col="dia_semana_entrega", col_wrap=4,data=df,kind="count", height=2.5, aspect=1.8)
  • Los días de entrega miércoles y jueves son aquellos que tienen una mayor tasa de entrega fallida.

Cumplimiento según tiempo de entrega

In [19]:
g = sns.catplot(x="cumplimiento", col="tramo_hr_desp", col_wrap=4,data=df,kind="count", height=2.5, aspect=1.8)

Correlaciones

Sobre las variables continuas, se revisa una matriz de correlaciones:

In [20]:
corr_matrix = df[['valor_contratado','distancia_envio_mts','horas_desde_creacion_hasta_compromiso','horas_desde_creacion_hasta_salida_primera_milla','satisfaccion_cliente']]
corrs = corr_matrix.corr()
figure = ff.create_annotated_heatmap(
    z=corrs.values,
    x=list(corrs.columns),
    y=list(corrs.index),
    annotation_text=corrs.round(4).values,
    showscale=True)
figure
  • Los atributos distancia_envio_mts y valor_contratado, tienen una correlacion (+) de (0.2851). Este valor se justifica dado que el tarifario de FAST se ajusta a el alza en la medida que los valores y los tiempos de servicio aumenten.
  • Los atributos horas_desde_creacion_hasta_compromiso y **valor_contratado", tienen una correlacion (+) de (0.0299). Este valor se justifica dado que el tarifario de FAST se ajusta a el alza en la medida que los valores y los tiempos de servicio aumenten.
  • Los atributos distancia_envio_mts y horas_desde_creacion_hasta_salida_primera_milla, tienen una correlación (-) de (-0.0297). Este valor se justifica por la composición de la operación que prioriza el transporte regional o de mayor distancia y las procesa en el momento que llegan a los centros operacinales para después dedicarse a los pedidos Locales o de menor distancia, por lo que mayor distancia, menor será la demora en el cierre del ciclo de la primera milla.

Sobre las variables categorias, se arman dummies y luego se revisa matriz:

In [21]:
df_3 = df.loc[:,['dimension_pedido','tipo_cliente','tipo_medicamento','velocidad_servicio','tipo_envio','tipo_induccion','tipo_servicio','admite_a_tiempo_para_pick_up']]
df_dum = pd.get_dummies(df_3, columns=['dimension_pedido','tipo_cliente','tipo_medicamento','velocidad_servicio','tipo_envio','tipo_induccion','tipo_servicio','admite_a_tiempo_para_pick_up'], drop_first=True)
plt.figure(figsize=(40,30))

corrs = df_dum.corr()

figure = ff.create_annotated_heatmap(
    z=corrs.values,
    x=list(corrs.columns),
    y=list(corrs.index),
    annotation_text=corrs.round(4).values,
    showscale=True)

for i in range(len(figure.layout.annotations)):
    figure.layout.annotations[i].font.size = 8

figure.show()
<Figure size 2880x2160 with 0 Axes>
  • Los atributos tipo_cliente (persona) y tipo_envio (Retiro en Ventanilla), tienen una correlación (+) de (0.3174). Este valor se justifica dado que el segmento contado, asociado a personas naturales prioriza el costo del envío por sobre la comodidad del retiro, priorizando el menor valor de una entrega de "Retiro en Sucursal", sobre el "Entrega a domicilio".
  • Los atributos tipo_cliente (persona) y **admite_a_tiempo_para_pick_up (si), tienen una correlación (-) de (-0.4418). Este valor se justifica dado que el segmento persona esta asociado casi excluisivamente a este tipo de inducción, del que hacen uso los tipo de clientes Empresa, para la logística en devolución.

Visualización de Outliers

Se realizan diagramas de cajas para revisar presencia de outliers:

In [22]:
box_plots(corr_matrix,2)

Se puede ver una gran cantidad de valores fuera de las cajas, lo que muestra alta presencia de valores escapados, sin embargo, pueden estar muy relacionados con la escala de los valores, por ejemplo en el caso del valor contratado el mínimo es 1.73 y el máximo sobre pasa los 6 millones,todo esto nos indica que una normalización del tipo logarítmica nos ayudaría a regularizar los datos. Se probará esta transformación cuando se realicen los modelos de clusterización y clasificación.

Test de medias

Para efectos de conocer el comportamiento de los atributos continuos según el vector objetivo, se procede a aplicar un Test de medias y así vislumbrar potenciales atributos con significancia estadística puedan ser usados en los posteriores análisis y modelos de este proyecto.

In [23]:
mean_test(df,'valor_contratado')
t = -226.73789542430694
p = 2.0
In [24]:
mean_test(df,'distancia_envio_mts')
t = -649.4974124293791
p = 2.0
In [25]:
mean_test(df,'horas_desde_creacion_hasta_compromiso')
t = 507.56059374033447
p = 0.0
In [26]:
mean_test(df,'horas_desde_creacion_hasta_salida_primera_milla')
t = -252.14155420008012
p = 2.0
  • Se puede observar que efectivamente, los tiempos de entrega determinarían el que sea exitoso un despacho o no. Lógicamente esto estaría condicionado a los intentos de entrega que hace la compañía.

Identificación segmentos de despachos

En este apartado el objetivo es encontrar los principales tipos de despachos que realiza FAST, y así determinar cuáles son los más propensos a que sean fallidos al término de su trayecto. Esto ayudaría a proponer acciones correctivas y enfocadas en el negocio.

En esta línea, desde una mirada exploratoria se definirán las variables son más ilustrativas para segmentar según una visualización de redes, aplicando teoría de grafos.

Finalmente, se implementará un modelo de clustering K-Means y el método de Elbow para definir cuántos grupos se pueden usar para la segmentación.

Visualización Grafo

Se genera una nueva categoria segun los cuartiles de la distancia en metros:

  1. Tramo corto: primer cuartil. (hasta 16025 metros)
  2. Tramo medio: segundo cuartil. (de 16026 a 90530 metros)
  3. Tramo largo: tercer cuartil. (de 90531 a 464025 metros)
  4. Tramo extra: cuarto cuartil. (sobre los 46026 metros)
In [99]:
df['tipo_tramo'] = np.where(df['distancia_envio_mts'].between(0, 16025),'TRAMO CORTO',
                            np.where(df['distancia_envio_mts'].between(16026,90530),'TRAMO MEDIO',
                                     np.where(df['distancia_envio_mts'].between(90531, 464025),'TRAMO LARGO',
                                     'TRAMO EXTRA' ) ))

Revisamos la distribucion de la nueva cagtegoria y vemos que queda de manera bastante balanceada:

In [100]:
sns.countplot(x="tipo_tramo", data=df);
In [9]:
atr = dict(df['tipo_cliente'].value_counts())
atr.update(dict(df['tipo_envio'].value_counts()))
atr.update(dict(df['tipo_induccion'].value_counts()))
atr.update(dict(df['tipo_servicio'].value_counts()))
atr.update(dict(df['tipo_tramo'].value_counts()))
atr.update(dict(df['tramo_hr_desp'].value_counts()))
related = {}

for ix, it in df.iterrows():
    tc = it['tipo_cliente']
    te = it['tipo_envio']
    ti = it['tipo_induccion']
    ts = it['tipo_servicio']
    tt = it['tipo_tramo']
    th = it['tramo_hr_desp']
    #print(tc,te, ti,ts, tt)
    if tc not in related:
        related[tc] = {}
    if te not in related[tc]:
        d = {te: 1}
        related[tc].update(d)
        related[te] = {}
    else:
        d = {te: related[tc][te] + 1}
        related[tc].update(d)
    if ti not in related[te]:
        d = {ti:1}
        related[te].update(d)
        related[ti] = {}
    else:
        d = {ti: related[te][ti] + 1}
        related[te].update(d)
    if ts not in related[ti]:
        d = {ts:1}
        related[ti].update(d)
        related[ts] = {}
    else:
        d = {ts: related[ti][ts] + 1}
        related[ti].update(d)
    if tt not in related[ts]:
        d = {tt:1}
        related[ts].update(d)
        related[tt] = {}
    else:
        d = {tt: related[ts][tt] + 1}
        related[ts].update(d)
        
    if th not in related[tt]:
        d = {th:1}
        related[tt].update(d)
        related[th] = {}
    else:
        d = {th: related[tt][th] + 1}
        related[tt].update(d)
g = nx.Graph()
# Add node for each character
for at in atr.keys():
    if atr[at] > 0:
        g.add_node(at, size = atr[at])
for rel in related.keys():
    for co_rel in related[rel].keys():
        
        # Only add edge if the count is positive
        if related[rel][co_rel] > 0:
            g.add_edge(rel, co_rel, weight = related[rel][co_rel])
pos_ = nx.spring_layout(g,iterations=10)
# For each edge, make an edge_trace, append to list
edge_trace = []
for edge in g.edges():
    
    if g.edges()[edge]['weight'] > 0:
        char_1 = edge[0]
        char_2 = edge[1]

        x0, y0 = pos_[char_1]
        x1, y1 = pos_[char_2]

        text   = char_1 + '--' + char_2 + ': ' + str(g.edges()[edge]['weight'])
        
        trace  = make_edge([x0, x1, None], [y0, y1, None], text,
                           0.000004*g.edges()[edge]['weight'])

        edge_trace.append(trace)
node_trace = go.Scatter(x         = [],
                        y         = [],
                        text      = [],
                        textposition = "top center",
                        textfont_size = 10,
                        mode      = 'markers+text',
                        hoverinfo = 'none',
                        marker    = dict(color = [],
                                         size  = [],
                                         line  = None))
# For each node in midsummer, get the position and size and add to the node_trace
for node in g.nodes():
    x, y = pos_[node]
    
    node_trace['x'] += tuple([x])
    node_trace['y'] += tuple([y])
    node_trace['marker']['color'] += tuple(['cornflowerblue'])
    node_trace['marker']['size'] += tuple([0.000007*g.nodes()[node]['size']])
    node_trace['text'] += tuple(['<b>' + node + '</b>'])
layout = go.Layout(
    paper_bgcolor='rgba(0,0,0,0)',
    plot_bgcolor='rgba(0,0,0,0)'
)

#grafico
fig = go.Figure(layout = layout)

for trace in edge_trace:
    fig.add_trace(trace)

fig.add_trace(node_trace)

fig.update_layout(showlegend = False)

fig.update_xaxes(showticklabels = False)

fig.update_yaxes(showticklabels = False)

fig.show()
  • Existen 2 macrosegmentos de despachos, concentrándose principalmente en el grupo de clientes Empresa, con envíos regionales, distancias mayores a los 90 km y con un servicio de ir a buscar el producto donde el cliente mismo y repartirlo hasta a la puerta del cliente final, asimismo, tiende a entregar sus despachos en un plazo máximo de 48 hrs.
  • Por otra parte, como el Macrosegmento 1 conforma sobre el 95% de la cartera de clientes, se puede encontrar un subgrupo que tiene repartos locales y que el producto se deje en una sucursal.
  • Macrosegmento 2 corresponde a personas que realizan despachos locales y regionales con un tipo de inducción en una oficina de FAST y retiro en la ventanilla.

K-Means

Estandarización de variables

Se crea un dataframe con los atributos de las variables para revisar presencia de clusters, en el caso de variables categóricas se transfroman con un label enconder y para las continuas una normalización logartímica.

In [101]:
#label encoder
le = LabelEncoder()
#se crea dataframe con atributos del cluster
clustering = df.loc[:,['tipo_cliente','tipo_envio','tipo_induccion'
                    ,'tipo_servicio','tipo_tramo','distancia_envio_mts'
              ,'horas_desde_creacion_hasta_compromiso']].copy()
categorical_cols = ['tipo_cliente','tipo_envio','tipo_induccion'
                    ,'tipo_servicio','tipo_tramo']
float_cols = ['distancia_envio_mts','horas_desde_creacion_hasta_compromiso']
# para las variables categoricas se transforman con label enconder y para las continuas una normalizacion logaritmica
clustering[categorical_cols] = clustering[categorical_cols].apply(lambda col: le.fit_transform(col))
clustering[float_cols] = clustering[float_cols].apply(lambda col: np.log1p(col))
clustering.head(10)
Out[101]:
tipo_cliente tipo_envio tipo_induccion tipo_servicio tipo_tramo distancia_envio_mts horas_desde_creacion_hasta_compromiso
0 1 0 0 1 1 13.31 4.20
1 0 0 1 1 2 12.09 2.83
2 1 0 0 1 1 13.34 3.30
3 0 0 1 1 1 14.12 4.53
4 0 0 1 1 1 13.73 3.09
5 0 0 0 1 1 13.20 4.29
6 0 0 1 1 1 14.32 2.89
7 0 0 1 1 2 12.88 2.89
8 0 0 1 0 3 10.13 3.04
9 0 0 1 1 2 12.84 2.83

Evaluación de número de clusters

Se genera un modelo de Kmeans y se evaluan sus resultados segun diferntes metodos:

Elbow Method
In [42]:
model = KMeans()
visualizer = KElbowVisualizer(model, k=(2,10),timings=True)
visualizer.fit(clustering)
visualizer.poof(); 

El gráfico muestra que con 4 clusters se entrega el modelo más eficiente, segun la metrica de 'distortion' que se calcula comparando la suma de las distancias cuadradas para cada centro.

In [12]:
visualizer = KElbowVisualizer(model, k=(2,10), metric='calinski_harabasz', timings=True)
visualizer.fit(clustering)    # Fit the data to the visualizer
visualizer.poof();

Utilizando la métrica de 'calinski harabasz' que se basa en el radio de dispersión de entre y para cada cluster, el número más eficiente para el modelo también son 4 grupos.

Dado los resultados mostrados anteriormente, se procede a aplicar el modelo KMeans con un número de 4 grupos, obteniendo los siguientes resultados:

In [182]:
model = KMeans(4,random_state=465)
model.fit(clustering)
Out[182]:
KMeans(n_clusters=4, random_state=465)

Se agrega el atributo del cluster al que pertenece cada registro a nuestro dataframe para revisar su distribución,

In [183]:
df['cluster'] = model.labels_
df['cluster'].value_counts('%')
Out[183]:
2   0.43
1   0.31
0   0.22
3   0.03
Name: cluster, dtype: float64

Se puede ver que la mayoría de los registros pertenecen al cluster 0 con un 43% de la data, luego al cluster 3 con un 31%, al cluster 1 con el 22% y finalmente al cluster 2 con el 3%.
Se renombra cada cluster con las siguientes categorias:

  • Cluster 2: 'Los Patiperros'
  • Cluster 1: 'Los poco optimizados'
  • Cluster 0: 'Business Package'
  • Cluster 3: 'Los Enfocados'
In [187]:
df["desc_cluster"] = df["cluster"].replace([0,2,3,1]
                                 ,["Business Package","Los Patiperros","Los Enfocados","Los Poco Optimizados"])  

Distribución de Variables continuas por Cluster

Se grafican en boxplots las atirbutos continuos para cada cluster y ver su comportamiento:

In [188]:
cob_columns_cont = []
cob_columns_cate = []

for index, (columnas_df,serie) in enumerate(df.iteritems()):
    if pd.api.types.is_float_dtype(serie) is True: 
        cob_columns_cont.append(columnas_df)
    else: 
        if pd.api.types.is_integer_dtype(serie) is True: 
            cob_columns_cont.append(columnas_df)
        else:
            cob_columns_cate.append(columnas_df)

cob_columns_cont.remove("cluster")   
cob_columns_cate.remove("desc_cluster")   
cob_columns_cate.remove("comuna_origen")
cob_columns_cate.remove("comuna_destino")   
plt.figure(figsize=(25,15))
for index,col in enumerate(cob_columns_cont):
    plt.subplot(3,3,index +1)
    sns.boxplot(x=df['desc_cluster'], y = df[col])

Conclusiones,

  • Se puede apreciar que el cluster de los Patiperros, es el grupo con mayor variabilidad en la distancia recorrida abarcando las mayores distancias, por esta razón se le bautiza de esa forma. Lo siguen los poco optimizados, luego los business package y finalmente los enfocados.
  • Los Patiperros presentan la mayor cantidad de outliers en el valor contratado, mostrando mayor variabilidad versus el resto de los clusters.
  • Con respecto a las horas desde creación hasta compromiso o hasta la primera milla se ve una distribución bastante uniforme, independiente del tipo de cluster los valores seran parecidos. Esto nos indica que puede existir cierta política de compromiso que se aplica a todos los pedidos por igual, independiente de su naturaleza.

Distribución de Variables categóricas por Cluster

In [189]:
plt.figure(figsize=(25,25))
for index,col in enumerate(cob_columns_cate):
    plt.subplot(6,3,index +1)
    sns.countplot(x=col, data = df, hue="desc_cluster")

Conclusiones,

  • En el último gráfico se puede apreciar de mejor manera la distribución de los cluster según su distancia, algo abarcado en la sección anterior, donde los patiperros se componen por tramos extras y largos, y los poco optimizados son los largos y medios. Los clusters de business package y enfocados son los tramos cortos a nivel local.
  • En el primer gráfico, se puede ver que independiente del tamaño del pedido, la distribución de los cluster es parecida para cada grupo.

Predicción tasa de despachos fallidos

Como se revisó en la seccion de analisis descriptivo, las variables continuas presentaban una alta variabilidad en escala y presencia de outliers, por lo que se decide por aplicar una trasnformacion logaritmica antes de continuar con el desarrollo del modelo predictivo.

In [77]:
### Logaritmo y graficos
continuas =['valor_contratado','distancia_envio_mts','horas_desde_creacion_hasta_compromiso','horas_desde_creacion_hasta_salida_primera_milla']
df['valor_contratado'] = np.log1p(df['valor_contratado'])
df['distancia_envio_mts'] = np.log1p(df['distancia_envio_mts'])
df['horas_desde_creacion_hasta_compromiso'] = np.log1p(df['horas_desde_creacion_hasta_compromiso'])
df['horas_desde_creacion_hasta_salida_primera_milla'] = np.log1p(df['horas_desde_creacion_hasta_salida_primera_milla'])
eda_plots(df[continuas],2)

Ahora se nota una distribución más parecida a una normal, por lo que se opta por trabajar los datos de esta manera. Se observan valores cero para el atributo distancia_envio_mts (3.2%), esto se da porque las distancias se calculan entre centros operacionales, y en algunos casos de tipos de envios "LOCALES", las distribución se realiza por el mismo centro operacional que la admite.

  • Para solucionar este tema se asignara la media del primer quantil del atributo distancia_envio_mts.
In [78]:
df["distancia_envio_mts"].describe()
df["distancia_envio_mts"].replace([0],df["distancia_envio_mts"].describe()[4],inplace = True)  

Se revisan resultados del cambio y se aprecia un comportamiento mucho más normalizado que antes,

In [24]:
eda_plots(df[continuas],2)

Preselección Atributos

  • Se excluirán los atributos relacionados con las "Comunas", ya que son muchas clases y bastante desbalanceadas, lo que podria llevar a un overfit del modelo segun la comuna.
  • Se excluyen datos como día de semana de entrega, satisfacción del clientes y tramos de hr, ya que son atributos que se conocen una vez entregado el pedido y no antes.
  • Se excluyen variables creadas para análisis anteriores como tramo de entrega y clusters generados.
In [79]:
df=df.drop(columns=['comuna_destino','comuna_origen','satisfaccion_cliente','dia_semana_entrega','tramo_hr_desp','dia_semana_admision','tipo_tramo','cluster','desc_cluster'])
df.columns
Out[79]:
Index(['dimension_pedido', 'tipo_cliente', 'tipo_medicamento', 'region_origen',
       'region_destino', 'score_datos_contactabilidad_destintario',
       'score_datos_contactabilidad_remitente', 'velocidad_servicio',
       'tipo_envio', 'tipo_induccion', 'cumplimiento', 'valor_contratado',
       'distancia_envio_mts', 'tipo_servicio', 'admite_a_tiempo_para_pick_up',
       'horas_desde_creacion_hasta_compromiso',
       'horas_desde_creacion_hasta_salida_primera_milla'],
      dtype='object')

 Binarización de variables categóricas

Se procede a binarizar las variables que son del tipo categórico:

In [80]:
df_modelo = pd.get_dummies(df, columns=['dimension_pedido','region_origen',
                                          'region_destino','tipo_cliente',
                                          'tipo_medicamento','velocidad_servicio',
                                          'tipo_envio','tipo_induccion',
                                          'tipo_servicio','admite_a_tiempo_para_pick_up'], drop_first=True)

Modelos candidatos

Preparación de variable objetivo.

In [28]:
df_modelo["cumplimiento"].replace(["si","no"]
                                 ,[1,0],inplace = True)  

Preparación de Muestras de entrenamiento y testeo.

In [29]:
columnas = df_modelo.columns
cols = list(columnas)
cols.remove("cumplimiento")
X_train, X_test,y_train,y_test = train_test_split(df_modelo.loc[:,cols],df_modelo["cumplimiento"],test_size=.33,random_state=202101)

Modelo AdaBoostClassifier

In [30]:
%%time
#Modelo Generico AdaBoostClassifier
Modelo_AdaBoost = AdaBoostClassifier()
Modelo_AdaBoost.fit(X_train, y_train)

y_hat_AdaBoost = Modelo_AdaBoost.predict(X_test)
CPU times: user 2min 22s, sys: 15.3 s, total: 2min 38s
Wall time: 2min 38s
In [31]:
metrics_model(Modelo_AdaBoost,X_test,y_test)
0 1 accuracy macro avg weighted avg
precision 0.76 0.75 0.75 0.76 0.76
recall 0.54 0.89 0.75 0.72 0.75
f1-score 0.63 0.81 0.75 0.72 0.74
support 333584.00 514153.00 0.75 847737.00 847737.00
In [ ]:
#dump(Modelo_AdaBoost, "aboost_model.joblib")

A nivel general, el modelo de AdaBoostClassifier tiene buen nivel de prediccion, con un accuracy del 75,4%. Sin embargo, al revisar su desempeño por categoria vemos que presenta un bajo rendimiento para los pedidos 0, es decir, los que no cumplen la entrega segun su promesa, con un recall de 54% lo cual es bastante bajo.

Modelo Decission Tree Classifier

In [32]:
%%time
dtc_model = DecisionTreeClassifier()
dtc_model.fit(X_train,y_train)
y_hat_dtc = dtc_model.predict(X_test)
CPU times: user 35.7 s, sys: 490 ms, total: 36.2 s
Wall time: 36.2 s
In [33]:
metrics_model(dtc_model,X_test,y_test)
0 1 accuracy macro avg weighted avg
precision 0.71 0.82 0.77 0.76 0.77
recall 0.72 0.81 0.77 0.76 0.77
f1-score 0.71 0.81 0.77 0.76 0.77
support 333584.00 514153.00 0.77 847737.00 847737.00
In [ ]:
#dump(dtc_model, "dtc_model.joblib")

A nivel general, el modelo de DecisionTreeClassifier tiene buen nivel de predicción, con un accuracy del 77,2%. Al revisar su desempeño por categoria vemos que presenta buen rendimiento para los dos tipos de pedidos, tanto los que cumplen como los que no cumplen. Se tiene presente que este tipo de modelo (arbol) tiende a un overfit sobre la data de entrenamiento, por lo que luego se realizará validación con una muestra de otro periodo.

Modelo Logistic Regression

In [34]:
%%time
log_model = LogisticRegression()
log_model.fit(X_train,y_train)
y_hat_log = log_model.predict(X_test)
CPU times: user 3min 30s, sys: 2min 21s, total: 5min 51s
Wall time: 30.6 s
In [35]:
metrics_model(log_model,X_test,y_test)
0 1 accuracy macro avg weighted avg
precision 0.72 0.73 0.73 0.72 0.72
recall 0.49 0.88 0.73 0.68 0.73
f1-score 0.58 0.79 0.73 0.69 0.71
support 333584.00 514153.00 0.73 847737.00 847737.00
In [ ]:
#dump(log_model, "log_model.joblib")

A nivel general, el modelo de Regresion Logistica tiene buen nivel de predicción, con un accuracy del 72,5%. Sin embargo, al revisar su desempeño por categoría vemos que presenta un bajo rendimiento para los pedidos 0, es decir, los que no cumplen la entrega según su promesa, con un recall de 49% lo cual es bastante bajo al no superar el 50%.

Gradient Boosting

In [36]:
%%time
gboost_model = GradientBoostingClassifier()
gboost_model.fit(X_train, y_train)

y_hat_GBoost = gboost_model.predict(X_test)
CPU times: user 9min 43s, sys: 1.23 s, total: 9min 44s
Wall time: 9min 44s
In [37]:
metrics_model(gboost_model,X_test,y_test)
0 1 accuracy macro avg weighted avg
precision 0.85 0.75 0.77 0.80 0.79
recall 0.51 0.94 0.77 0.73 0.77
f1-score 0.64 0.83 0.77 0.74 0.76
support 333584.00 514153.00 0.77 847737.00 847737.00
In [ ]:
#dump(gboost_model, "gboost_model.joblib")

A nivel general, el modelo de GradientBoostClassifier tiene buen nivel de prediccion, con un accuracy del 77,3%. Sin embargo, al revisar su desempeño por categoría vemos que presenta un bajo rendimiento para los pedidos 0, es decir, los que no cumplen la entrega segun su promesa, con un recall de 51% lo cual es bastante bajo.

Comparación de modelos

A nivel general el accuracy de todos los modelos se mueve entre el 72% y el 77%, lo cual es un nivel aceptable de desempeño del modelo, siendo el mejor el Gradient Boosting:

  1. GradientBoosting: 77,3%
  2. DecisionTree: 77,2%
  3. AdaBoost: 75,4%
  4. Logistic Regression: 72,5%

El objetivo del modelo es predecir el cumplimiento de la promesa de entrega que se le notifica al cliente, en este sentido nos es más relevante poder identificar de manera correcta los pedidos que no cumplirian con el tiempo comprometido, es decir los de clasificacion 0, por este motivo al revisar el indicador de desempeño del recall para los clase 0:

  1. DecisionTree: 72%
  2. AdaBoost: 54%
  3. GradienBoosting: 51%
  4. Logistic Regression: 49%

Se puede ver que el modelo de DecisionTree presenta el mejor desempeño por bastante rango versus el resto de los modelos, con un 72% de recall para las categorias 0. Tambien se validaron los modelo con una muestra de datos de otro periodo, el accuraccy y metricas de desmepeño bajaron en aprox 5 puntos porcentuales, pero la conclusion es la misma, el modelo de Decision Tree entrega los resultados mas acertados. (Para revisar validacion revisar notebook adjunto: 'Validación_Modelos_FAST.ipynb')

Si revisamos los atributos más importantes para el modelo de Decision Tree podemos ver que:

In [52]:
best_attr = plot_importance(dtc_model,X_train.columns)

El atributo más importante para el modelo son la distancia en metros, luego las horas de creación hasta la salida de la primera milla y las horas de creación hasta la fecha comprometida, es decir, cuanto tiempo de plazo se tiene para la entrega.

Conclusiones

Se traen las categorías obtenidas en con el modelo de clusterizacion y se seleccionan los grupos, mayores a 10.000 pedidos que tengan una baja probabilidad de cumplimiento según el modelo de predicción generado.

In [58]:
%%bigquery clusters
select *
from `charged-ground-301216.test_1_fast_project.cluster_persona`
In [61]:
columnas = df_modelo.columns
cols = list(columnas)
cols.remove("cumplimiento")
yhat_dtc=dtc_model.predict(df_modelo.loc[:,cols])
clusters['pred']=yhat_dtc
grupos=clusters.groupby(['cluster', 'tipo_cliente','tipo_envio', 'tipo_induccion', 'tipo_tramo']).pred.agg(['count', 'mean'])
grupos[(grupos['count']>10000) & (grupos['mean']<0.6)].sort_values(by='count', ascending=False).sort_values(by=['count','mean'], ascending=False)
Out[61]:
count mean
cluster tipo_cliente tipo_envio tipo_induccion tipo_tramo
2 empresa a_la_puerta retirado_a_cliente TRAMO EXTRA 491351 0.56
TRAMO LARGO 348908 0.59
1 empresa a_la_puerta retirado_a_cliente TRAMO LARGO 127446 0.59
2 empresa retiro_ventanilla retirado_a_cliente TRAMO EXTRA 77000 0.58
TRAMO LARGO 54109 0.59
persona retiro_ventanilla dejado_sucursal TRAMO EXTRA 33081 0.57
a_la_puerta dejado_sucursal TRAMO EXTRA 32844 0.58
TRAMO LARGO 30780 0.56
retiro_ventanilla dejado_sucursal TRAMO LARGO 27571 0.56
1 empresa retiro_ventanilla retirado_a_cliente TRAMO LARGO 18423 0.59
persona retiro_ventanilla dejado_sucursal TRAMO MEDIO 14279 0.57
a_la_puerta dejado_sucursal TRAMO LARGO 13803 0.58
retiro_ventanilla dejado_sucursal TRAMO LARGO 12020 0.58

Se ve que existen 15 grupos principales al evaluar los peores grupos con un Q de pedidos que son representativos para la muestra, y principalmente se ve que:

  • Todos los grupos pertenece al cluster-2: 'Los Patiperros' y cluster-1:'Los poco optimizados'.
  • Pertenece a tramos clasificados como Largos o extras, esto se explica ya que en le modelo de predicción el atributo más importante es la distancia recorrida.
  • La gran mayoría de pedidos caen en el segmento empresa, pero aun así hay gran cantidad de pedidos persona.